Skip to content

feat: Signing & Trust v1 (milestone 2) - #203

Open
michael-herwig wants to merge 14 commits into
mainfrom
feat/signing-and-trust
Open

feat: Signing & Trust v1 (milestone 2)#203
michael-herwig wants to merge 14 commits into
mainfrom
feat/signing-and-trust

Conversation

@michael-herwig

@michael-herwig michael-herwig commented Jul 9, 2026

Copy link
Copy Markdown
Contributor

Milestone 2 — Signing & Trust v1

Delivers keyless Sigstore sign + verify over the OCI 1.1 Referrers API, identity-pinned trust policy, offline/air-gapped verify, and default-on auto-verify. Built on a long-living branch via per-issue swarm-loops; each feature is one commit, rebased cleanly onto current main (v0.4.2).

Closes #194 #195 #196 #98 #99 #106

What shipped (one commit per issue)

Plus one integration commit adapting the milestone onto the current main: origin/main's SelectResult::FeatureMismatch variant, the slimmed 4-arg OciTransport::pull_blob_to_file, the new config command group (doc + coverage-test sync), and the upstream oci-client adoption (see below).

Submodule (external/rust-oci-client) — CI prerequisite

The branch adopts upstream oci-client 7d27d20e (already on main) plus one commit that adds pull_referrers_native — a native-only referrers lookup that returns Ok(None) on a /v2/<name>/referrers/<digest> 404 instead of the spec tag-schema fallback. Upstream's pull_referrers silently swallows that 404 into an empty list, which would downgrade ReferrersUnsupported (84) to NoSignaturesFound (79). The commit is on branch ocx/referrers-native-detection of ocx-sh/rust-oci-clientmust be pushed before CI can build.

Deferred (stay open, tracked separately)

Known scope (documented in signing.md § Deferred to Future Work)

v1 is keyless sign/verify against operator-supplied trust material + the in-repo fake Sigstore stack. Production public-good Sigstore (live Fulcio chain validation, standard Rekor wire SET, TUF trust root, cosign interop) is deferred and honestly documented — not claimed.

Verification

  • task verify (basic gate): green — 3016 Rust unit + ~1576 acceptance, clippy -D warnings, cargo-deny, hawkeye, actionlint, AI-config structural, link-check all clean.
  • Deep gate (verify-deep.yml): zot + registry:2 fixtures wired.

@github-actions

github-actions Bot commented Jul 9, 2026

Copy link
Copy Markdown

Unit Test Results

0 tests   - 2 882   0 ✅  - 2 882   0s ⏱️ -34s
0 suites  -     6   0 💤 ±    0 
0 files    -     1   0 ❌ ±    0 

Results for commit e33cfe3. ± Comparison against base commit 9978cc2.

♻️ This comment has been updated with latest results.

Keyless Sigstore signing and verification surfaced as OCI Referrers
artifacts. Adds oci::sign / oci::verify / oci::referrer pipelines
(Fulcio cert, OIDC ambient + browser flows, Rekor log, trust root,
identity policy), package_manager sign/verify tasks, the
ocx package sign / ocx package verify CLI commands, a typed error
taxonomy with dedicated exit codes (RekorUnavailable 83,
ReferrersUnsupported 84), and OCX_IDENTITY_TOKEN handling that is never
forwarded to subprocess children.
Planning artifacts (ADRs, PRDs, PR-FAQs, research, swarm review rounds,
Codex cross-model gates), exit-code rule additions (RekorUnavailable 83,
ReferrersUnsupported 84), subsystem-cli command rows, and a structural
test asserting every command file has a table row.
Upgrade the primary acceptance registry from registry:2 to zot v2.1.18,
which serves the OCI 1.1 Referrers API natively (GET /v2/<name>/referrers/
<digest> + OCI-Subject on push). distribution (BOTH registry:2 and
registry:3) does NOT implement the Referrers API — verified empirically:
registry:3 (distribution 3.1.1) returns Go's "404 page not found" on the
referrers route and omits OCI-Subject. OCX is referrers-only with no
tag-schema fallback (#106), so the supply-chain sign/verify path (#194) and
the smoke test require a real referrers endpoint. zot is pinned to a
multi-arch tag (linux/amd64 + linux/arm64) with a mounted read-only config.

Keep the single registry:2 (mirror-registry, port 5001) as the permanent
referrers-NEGATIVE fixture, exposed via a new `legacy_registry` fixture for
test_referrers_capability.py (#106); it still backs the mirror tests.

Add raw-HTTP referrers helpers (push_blob / push_manifest / push_minimal_
image / push_referrer / list_referrers) to src/registry.py and a smoke test
(test_referrers_smoke.py) that pushes a subject + referrer to zot and lists
it back, and asserts the registry:2 negative fixture does not serve
referrers.

Refs #195.
Fill the slice-1 scaffold with a working keyless Sigstore sign + verify
pipeline over OCI 1.1 Referrers:

- Sign: ephemeral P-256 key -> Fulcio cert -> ECDSA sign of the subject
  digest -> Rekor hashedrekord upload -> Sigstore bundle v0.3 -> push
  bundle blob + referrer manifest. OIDC dispatch (override -> GHA/GitLab/
  CircleCI ambient -> browser/no-tty). Capability cache wired (exit 84).
- Verify: list referrers -> fetch bundle -> validate Fulcio chain vs
  injected trust root -> bind message signature to subject digest -> ECDSA
  verify -> Rekor SET verify (Ed25519, key fetched from --rekor-url) ->
  byte-equal identity + issuer match.
- NativeTransport list_referrers/push_referrer_manifest over the OCI 1.1
  Referrers API incl. server-side ?artifactType= filter.
- Trust-root seam: --trust-root flag + OCX_SIGSTORE_TRUST_ROOT env (Fulcio
  CA PEM); embedded/TUF default is network-gated.
- Endpoint lift to oci::endpoint (ADR Amendment 2).
- deps: sigstore 0.14 + sigstore_protobuf_specs + p256/x509-cert/
  ed25519-dalek + reqwest (rustls). rsa RUSTSEC-2023-0071 ignored in
  deny.toml (transitive via openidconnect; OCX is ECDSA-only).

Spike + plan: research_sigstore_rs_spike.md, plan_issue194_sigstore_pipeline.md.
Hand-rolls the HTTP because sigstore-rs's high-level clients mandate an SCT
and a different wire shape than the offline fake stack.
Extend `ocx package verify` with trust-policy identity pinning so a
typosquat carrying a valid-but-wrong Sigstore identity is rejected.

- New leaf module `crate::trust`: `[[trust.policy]]` schema (scope +
  identity XOR identity_regexp + oidc_issuer), literal-prefix scope
  match, most-specific-wins + ANY-of-among-equal resolution, anchored
  full-match regex identities.
- Cross-tier precedence (security): operator `config.toml` tiers
  array-append into one authoritative set; `resolve_tiered` consults the
  project `ocx.toml` only for scopes no operator policy governs, so a
  project file can never override or weaken an operator pin.
- CLI: `--certificate-identity`/`--certificate-oidc-issuer` become
  optional (both-or-neither via clap `requires`); both given override
  policy (flag-only verify unchanged); neither uses the matched policy
  set. New `VerifyErrorKind::NoIdentityProvided` (exit 64) and
  `TrustPolicyInvalid` (exit 78).
- `trust` field on `Config` + `ProjectConfig` (excluded from the
  declaration hash so a policy edit never invalidates `ocx.lock`).

Verify reading `ocx.toml` for trust policy is a documented OCI-tier
carve-out, scoped solely to `[[trust.policy]]`.
Make `ocx package verify` offline-capable and close #194's Rekor-key
trust-on-first-use hole.

- Trust-root cache in `$OCX_HOME/state/trust_root/<rekor-authority>.json`
  (mirrors the referrers capability cache): a successful online verify
  caches the Fulcio CA + Rekor key it used; a later verify reuses it.
- `--tuf-root` / `OCX_SIGSTORE_TUF_ROOT`: load a Sigstore trusted-root
  JSON (Fulcio CA + pinned Rekor key) for air-gapped verify. No TUF
  network fetch — deferred, documented.
- Rekor key pinning: verify prefers the trust-root Rekor key (TUF/cache)
  over the network TOFU fetch, falling back to fetch only when online and
  unpinned.
- Offline semantics: for verify, `--offline`/`OCX_OFFLINE` scopes to the
  Sigstore trust services (Rekor-key fetch, TUF), not the artifact
  registry. Offline verify requires cached/supplied trust material with a
  pinned Rekor key; with none it fails exit 78 naming the remedy — never
  silently skips. `sign` stays online-only. The cache read + into_trust_root
  is the reusable offline decision seam for #99.

Docs: environment.md (OCX_SIGSTORE_TUF_ROOT, OCX_OFFLINE x verify),
signing.md (offline section + Rekor-pinning limitation update),
user-guide air-gapped verify. ADR: adr_offline_verify_trust_cache.md.
When an operator [[trust.policy]] covers a package, `ocx package install`
and `ocx package pull` now verify its keyless Sigstore signature at the
metadata-first seam — after the manifest resolves, before any layer
download — and abort fail-closed on a covered-but-invalid signature (no
package-store or symlink state). No matching policy -> no verify (opt-in
trust). `--no-verify` / `OCX_NO_VERIFY` opt out with a single WARN; the
flag wins over the env.

- PackageManager::verify_one: single lib verify facade over
  VerifyPipeline::run (#194); wraps VerifyError so the exit code survives
  the batch classifier.
- AutoVerify + maybe_auto_verify hook (leader-only in setup_impl): gates
  on trust::resolve_tiered (#98), resolves trust material via the shared
  offline-aware ladder (#196), verifies against the resolved leaf digest.
- oci::verify::resolve_trust_root: shared trust-root ladder (env/flag
  override -> cache -> embedded, offline gate) — one source of the
  offline gate for package verify and auto-verify.
- OCX_NO_VERIFY env + OcxConfigView forwarding; --verify/--no-verify
  reused from options/verify.rs (flag > env).
- Docs: environment.md, command-line.md, user-guide.md.
- test/tests/test_auto_verify.py: 8 acceptance tests.
… v3 interop (#197)

payload format and a single-hop certificate chain, not the public Sigstore wire
format. That makes both #107 and #197 infeasible to ship in this milestone
regardless of scheduling — Rekor v2 has no sigstore-rs client yet, and cosign
cannot validate a bundle that isn't real-Sigstore-shaped no matter how the
trust root is supplied. Ties the whole production-hardening cluster (Fulcio
chain walking, Rekor SET format, TUF refresh, Rekor v2, cosign interop) into
one roadmap section, and fixes an overclaim in the `package sign` reference
that implied cosign could already verify OCX signatures.
Rebasing the milestone onto main (v0.4.2) required adapting to changes
that landed after the branch was cut:

- oci-client submodule advanced to upstream 7d27d20e (CA-root seeding,
  chunked push, referrers-with-artifactType). The branch's fork became
  redundant and is dropped; native_transport now uses upstream's
  ImageIndexEntry. Upstream's pull_referrers spec-fallback silently
  swallows a native 404 into an empty list, which would downgrade
  ReferrersUnsupported (84) to NoSignaturesFound (79); native_transport
  now calls the new pull_referrers_native (submodule) so the exit-84
  contract survives.
- SelectResult gained a FeatureMismatch variant (libc differentiation);
  sign/verify pipelines fold it into the no-target-manifest arm.
- OciTransport::pull_blob_to_file slimmed to 4 params; the referrer
  capability test mock follows.
- The managed-config [managed] tier and [trust] policy now coexist on
  Config (both merged); config_trust is cloned so the full config still
  moves into Context.
- New 'ocx config' command group (managed-config) documented in
  subsystem-cli-commands.md; the branch's own CLI-coverage test learns
  the config group.
Eliminate the Block-tier "Never Re-Construct a Store" violation where two
private cache_path free functions hardcoded the state/ layout via literal
.join("state") chains.

Add named accessors StateStore::referrers_capability_file and trust_root_file
(slug via to_relaxed_slug, byte-identical paths) as the single layout owner,
mirroring the managed_config_* accessors. Retype the two cache seams
(ReferrersApiCapability, TrustRootCache) and thread a real &StateStore handle
through the sign/verify pipeline instead of downgrading to &Path via .root():
SignContext, VerifyContext, resolve_trust_root, VerifyOptions, AutoVerify, and
the CLI/context origin sites.

Behavior-preserving (Two Hats): on-disk paths unchanged, verified by
accessor-level path assertions plus the sign/verify acceptance suites.
Six call sites hand-rolled the same "write bytes to a 0o600 tempfile in the
target's parent dir, then publish atomically via persist_temp_file" sequence.
Extract it into utility::fs::write_bytes_atomic(target, bytes) and fold all six
onto it: the referrers + trust-root caches, the managed-config snapshot and
pause writers, and the setup profile + shim writers (deleting the local
write_atomic wrapper). Each site keeps its own create_dir_all and spawn_blocking.

Add the helper to the Utility Catalog (arch-principles.md) so it is discovered
before the next hand-rolled copy. Behavior-preserving: tempfile defaults to
0o600 on Unix, so the four plain NamedTempFile::new_in sites already produced
0o600 files; target paths and bytes are unchanged.

Excluded (folding would drop guarantees): blob_store (content-addressed
idempotency re-check + sync_data), project/{mutate,lock} (raw persist without
Windows retry, sync_data + parent-dir fsync durability, prior-perms
preservation), and state_store::touch_atomic (empty-marker touch, deliberate
plain rename).
Adversarial review (8-worker panel + Codex gpt-5.6-sol) of the
signing & trust milestone surfaced 4 Block + ~19 actionable Warn
findings across security, correctness, architecture, docs, and CLI.
This applies them; the milestone's honest deferrals (cert-time
binding, embedded TUF root, private SET / cosign interop) stay
tracked for #205/#197.

Security / correctness (verify pipeline):
- Bind the Rekor transparency-log body to the bundle (parse the
  hashedrekord body; assert its digest/signature/cert match the
  subject + bundle + verified leaf). Closes the GHSA-whqx-f9j3-ch6m
  splice class where a leaked expired ephemeral key could attach a
  previously-valid SET to a new subject. New TransparencyBodyMismatch
  -> exit 65.
- Cap the bundle-blob read pre-download and stream with take(MAX+1)
  so a registry lying about descriptor size cannot force an unbounded
  allocation (CWE-400); bound the referrer-manifest read by actual
  bytes (full streaming close deferred to #205).
- ANY-of referrer verification: iterate every matching signature
  referrer (bounded count + byte budget), succeed on the first full
  crypto+policy pass, return a severity-ranked aggregate on failure.
  Fixes key rotation and the malformed-first-referrer DoS; a truncated
  candidate set returns CandidateLimitExhausted rather than
  misreporting a signed artifact as unsigned.
- Regression fix: native_transport dropped the referrer descriptor
  artifactType, so the new client-side filter discarded every
  server-matched referrer. Preserve it at the transport; make the
  pipeline re-filter tolerant.
- Shared, timed reqwest client for all four Sigstore call sites so a
  stalled endpoint cannot hang the fail-closed verify gate.
- Replace a blocking is_dir() in the trust-root ladder; memoize the
  Rekor key per invocation; skip content-equal trust-cache writes.

Architecture (ADR Amendment 1 -> Option 3):
- SignPipeline/VerifyPipeline take &Client; demote Client::transport()
  to pub(crate); drop &dyn OciTransport from the public Sign/Verify
  contexts + VerifyOptions. Implement sign_one and route both CLI
  commands through the PackageManager facade (removes a reachable
  unimplemented!() panic + duplicated context assembly). SignOptions
  carries a Zeroizing identity token.

Docs / schema truth:
- Fix the trust.rs scope rustdoc (segment-boundary, not substring)
  schemars publishes into the config/project schemas; correct 9
  website drifts; strip C-S1-* clause labels from clap help and
  extend the help-hygiene gate to catch them.

CLI quality:
- Single-source OCX_NO_VERIFY; split the shared verify help so
  install/pull states signature verification + OCX_NO_VERIFY; restore
  error.context.identifier on facade-wrapped failures; rename
  command/verify.rs -> package_verify.rs (+ catalog row); path context
  on the project-config read.

Tests: SET-body splice, ANY-of rotation, malformed-first, size-cap,
capped reads, envelope-identity-on-PackageError, sign 503->83 /
wrong-key->80, offline warm-cache->77, ocx-run gate->79. The
idempotent-re-sign test is xfail(strict) pending S1-I dedup.

Deferred (tracked): streaming referrer-manifest read (#205),
capability-probe singleflight, S1-I idempotent re-sign.
Post-rebase cross-file integration fixes the per-commit conflict
resolution could not see:

- sign/verify pipelines: Index::select now takes &Platform under the
  unified platform model; drop the Vec argument form
- canonical_command_name: pick up main's upgrade->update rename and the
  new config setup subcommand
- test_ai_config: map config_setup stem to 'config setup' in the
  command-table coverage test (test landed on branch, command on main)
@michael-herwig
michael-herwig force-pushed the feat/signing-and-trust branch from d31be9d to e33cfe3 Compare July 18, 2026 17:36
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Implement sign/verify pipeline via sigstore-rs (slice 2)

1 participant